昨天,我們建立了後端防鎖與衝突檢查機制,確保系統在 concurrent(高並發)預約時能夠做到零重複預約(No Overbooking)。
今天,我們將串接 Telegram Bot 與 GAS 原生 Email 服務(MailApp)。當申請人提交預約時,後端將即時進行審核與衝突比對:若預約成功,系統會自動寫入 Google 行事曆並同步透過 Telegram 與 Email 推播通知給申請人;若預約失敗,則直接在網頁端給予即時反饋。
本系統的預約與通知流程如下:
┌──────────────────────────────────────────────────────────┐
│ 1. 查看 Google Calendar 看板(確定可預約時段) │
└────────────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 2. 於 WebApp 填寫預約表單(包含 Email 與 Telegram Chat ID) │
└────────────────────────────┬─────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 3. 後端審核與衝突比對 (LockService) │
└───────────────┬──────────────────────────┬───────────────┘
│ │
【預約失敗 (時段重覆)】 【預約成功】
│ │
▼ ▼
┌────────────────────────┐ ┌─────────────────────────────┐
│ 網頁端即時彈窗反饋錯誤 │ │ 1. 寫入 Google Calendar │
│ (不發送推播通知) │ │ 2. Telegram Bot 直播推播 │
└────────────────────────┘ │ 3. 發送 Email 個人備忘 │
│ 4. 網頁顯示預約成功資訊 │
└─────────────────────────────┘
為了讓 Telegram Bot 能夠直接發送個人訊息給申請人,需完成以下設定:
@BotFather,發送 /newbot 指令,依提示完成建立並取得 Bot Token。@userinfobot 或發送 /start 給你建立的 Bot,即可取得個人專屬的 Chat ID。Email 與 Telegram Chat ID 兩個必填/選填欄位,以便後端推播。NotificationService.gs)新增 NotificationService.gs 檔案,專門處理 Telegram 與 Email 的發送邏輯:
// 填入從 BotFather 取得的 Telegram Bot Token
const TELEGRAM_BOT_TOKEN = 'YOUR_TELEGRAM_BOT_TOKEN';
/**
* 預約成功:觸發 Telegram + Email 個人通知
* @param {Object} data 預約表單資料
*/
function sendSuccessNotifications(data) {
// 1. 若有提供 Telegram Chat ID,發送 Telegram 直播訊息
if (data.telegramChatId) {
sendTelegramDirectMessage(data);
}
// 2. 若有提供 Email,發送 Email 個人確認信
if (data.applicantEmail) {
sendEmailConfirmation(data);
}
}
/**
* 發送 Telegram 個人訊息給申請人
*/
function sendTelegramDirectMessage(data) {
const url = `https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage`;
const messageText =
`🎉 *【場地預約成功通知】*\n\n` +
`📍 *預約場地:* ${data.roomName}\n` +
`👤 *申請人:* ${data.applicantName}\n` +
`⏰ *開始時間:* ${data.startTime.replace('T', ' ')}\n` +
`⌛ *結束時間:* ${data.endTime.replace('T', ' ')}\n` +
`📝 *活動用途:* ${data.purpose}\n\n` +
`👉 *備忘提醒:* 行事曆事件已順利建立,請準時使用。`;
const payload = {
chat_id: data.telegramChatId, // 直接發送給申請人個人的 Chat ID
text: messageText,
parse_mode: 'Markdown'
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
try {
const response = UrlFetchApp.fetch(url, options);
Logger.log("✅ Telegram 個人通知發送結果: " + response.getContentText());
} catch (error) {
Logger.log("❌ Telegram 發送失敗: " + error.toString());
}
}
/**
* 發送 Email 個人備忘至申請人信箱
*/
function sendEmailConfirmation(data) {
const subject = `【預約成功】已為您確認 ${data.roomName} 場地預約`;
const htmlBody = `
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #2b5876;">您好,${data.applicantName}:</h2>
<p>您已成功完成場地預約,詳細資訊如下:</p>
<table style="width: 100%; border-collapse: collapse; margin: 15px 0;">
<tr><td style="padding: 8px; border-bottom: 1px solid #ddd;"><b>預約場地:</b></td><td style="padding: 8px; border-bottom: 1px solid #ddd;">${data.roomName}</td></tr>
<tr><td style="padding: 8px; border-bottom: 1px solid #ddd;"><b>使用時間:</b></td><td style="padding: 8px; border-bottom: 1px solid #ddd;">${data.startTime.replace('T', ' ')} ~ ${data.endTime.replace('T', ' ')}</td></tr>
<tr><td style="padding: 8px; border-bottom: 1px solid #ddd;"><b>活動用途:</b></td><td style="padding: 8px; border-bottom: 1px solid #ddd;">${data.purpose}</td></tr>
</table>
<p style="color: #666; font-size: 0.9em;">此信件為系統自動發送,該預約已同步寫入 Google 行事曆看板。</p>
</div>
`;
try {
MailApp.sendEmail({
to: data.applicantEmail,
subject: subject,
htmlBody: htmlBody
});
Logger.log("✅ 預約確認信已寄送至: " + data.applicantEmail);
} catch (error) {
Logger.log("❌ Email 發送失敗: " + error.toString());
}
}
Code.gs)將審核邏輯、Google 日曆同步、雙軌推播與網頁即時反饋整合成完整的服務端處理函式:
/**
* 處理預約表單提交 (含衝突檢查、日曆同步與通知)
*/
function handleCreateBookingWithLock(data) {
const lock = LockService.getScriptLock();
// 等待最多 10 秒以取得鎖定
if (!lock.waitLock(10000)) {
return createJsonResponse({
status: 'ERROR',
message: '系統繁忙中,請稍後再試。'
});
}
try {
const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
const start = new Date(data.startTime);
const end = new Date(data.endTime);
// 1. 審核:時段衝突比對 (Overbooking Check)
const conflicts = calendar.getEvents(start, end);
if (conflicts.length > 0) {
// ❌ 預約失敗:解鎖並即時回傳錯誤訊息,由前端網頁直接顯示反饋
lock.releaseLock();
return createJsonResponse({
status: 'ERROR',
message: '預約失敗:該時段已被其他人預約,請重新選擇其他時間。'
});
}
// 2. 審核通過:建立 Google Calendar 事件
const eventTitle = `【預約】${data.roomName} - ${data.applicantName}`;
const eventDescription = `預約人:${data.applicantName}\n用途:${data.purpose}\nEmail:${data.applicantEmail}`;
const event = calendar.createEvent(eventTitle, start, end, {
description: eventDescription,
location: data.roomName
});
// 3. 釋放鎖定
lock.releaseLock();
// 4. 🎉 預約成功:發送 Email 與 Telegram 給申請人
sendSuccessNotifications(data);
// 5. 回傳成功狀態與資訊至前端網頁
return createJsonResponse({
status: 'SUCCESS',
eventId: event.getId(),
message: '預約成功!已同步發送通知至您的 Email 與 Telegram。'
});
} catch (error) {
lock.releaseLock();
return createJsonResponse({
status: 'ERROR',
message: '系統發生異常:' + error.toString()
});
}
}
當前端呼叫後端 API 後,根據回傳的 status 給予使用者即時反饋:
async function submitBooking(formData) {
try {
const response = await fetch(GAS_API_URL, {
method: 'POST',
body: JSON.stringify(formData)
});
const result = await response.json();
if (result.status === 'SUCCESS') {
// 成功處理:顯示成功訊息與提示通知已寄出
alert(`✅ ${result.message}`);
// 清空表單或跳轉頁面
} else {
// ❌ 失敗處理:即時在網頁畫面上彈出錯誤提示
alert(`⚠️ ${result.message}`);
}
} catch (err) {
alert("網絡連線異常,請稍後再試。");
}
}
今天我們完成了一套流暢的預約與通知機制:
明日繼續……